(2024/04/06更新) 因應React在18後更新了許多不同的語法,更新後的教學之後將陸續放在 新的blog 中,歡迎讀者到該處閱讀,我依然會回覆這邊的提問
通常當我們要設定state時,都是透過setState(要指定的值)。但這樣做有兩個問題:
setState的元件可以任意指定值給state
state結構複雜、但我們又只有要修改其中部分值時,很容易出錯。舉例來說,這次有個鐵人賽參賽者(我忘記在哪看到的了)提及他想要用這樣的方式來處理資料:
const [data, setData] = useState({ A: a, B: b });
然後他想分別建立兩個單獨設定A和B的按鍵:
<button onClick={()=>{ setData({A: newA}) }}></button>
<button onClick={()=>{ setData({B: newB}) }}></button>
然而這樣的寫法是錯的。因為useState給出來的setData函式並不會自動去修改物件中的單一屬性,而是直接把你丟給setData的參數整個變成data新的值。以A為例,按下設定A的按鍵後,新的data不會是{ A: newA, B: b },而是{ A: newA }。
最後,那位參賽者用ES6的spread operator展開原始的data物件,解決這個問題。
<button onClick={()=>{ setData({...data, A: newA}) }}></button>
<button onClick={()=>{ setData({...data, B: newB}) }}></button>
雖然這樣做的確解決了他的case,但是如果物件資料變的很複雜呢?如果我們要修改的結構散佈在物件各層呢? 要如何才能確保state的修改不會被同事改錯呢?
因為剛剛的問題在大型網站上常常出現,Facebook的開發者針對這點提出了Flux設計模式。這裡我們不會詳述Flux,不過簡而言之就是當我們在做資料管理、流程設計時,不應該讓別人能夠隨意修改,而是我們要預先定義好修改的規則,並讓其他開發者透過這些規則來操作。
在Flux觀念下,我們操作流程和資料的過程大概變成像這樣:
由於React最通用的狀態管理工具Redux(下一篇會講它)是採用Flux結構,而在Redux中reducer跟store扮演的角色是一樣的,所以我們這裡放入說明的同一個地方。接下來的說明我們也會以Redux的架構為主

useReducer是React提供用來簡易實現Flux架構的React hook,基本上它就是一個「能夠預先定義state設定規則」的useState。
和useState不同的是,useReducer必須要接收兩個參數。第一個是函式,要定義有哪些規則、規則對應的邏輯。第二個則是state的初始值。useReducer的語法為下:
const [state, dispatch] = useReducer(reducerFunc, initStateValue);
操作者可以透過dispatch函式傳送參數:
dispatch({type: "ADD"})
當操作者呼叫dispatch後,reducerFunc會被呼叫並接收到兩個參數。第一個是state先前的值,第二個則是操作者剛剛傳入dispatch的參數。reducerFunc必須要接收一個回傳值,這個回傳值會變成state新的值:
const reducerFunc = function(state, action){
    // action get { type:"ADD" }
    switch(action.type){
        case "ADD":
            return state+1; // new State
        case "SUB":
            return state-1; // new State
        default:
            throw new Error("Unknown action");
    }
}
以上面的那位參賽者的狀況來說,它可以改成這樣:
const reducer = function(state, action){
    // 由於JS物件類似call by ref,先複製一份避免直接修改造成非預期錯誤
    const stateCopy = Object.assign({}, state);
    
    switch(action.type){
        case "SET_A":
            stateCopy.A = action.A;
            return stateCopy; // new State
        case "SET_B":
            stateCopy.B = action.B;
            return stateCopy; // new State
        default:
            throw new Error("Unknown action");
    }
}
之後只要這樣使用,程式碼就會更直觀,也能避免不小心在哪個地方寫錯導致state被覆蓋:
<button onClick={()=>{ dispatch({type: "SET_A", A: newA}) }}></button>
<button onClick={()=>{ dispatch({type: "SET_B", B: newB}) }}></button>
現在,我們來試著讓先前的isOpen改成用useReducer改變。
在src/page/MenuPage.js中,先定義reducer:
const reducer = function(state, action){
    switch(action.type){
        case "SWITCH": 
            return !state; // 只有開/關
        default:
            throw new Error("Unknown action");
    }
}
接著引入useReducer,並在元件中取得state和dispatch
import React, { useState, useReducer,useMemo,useEffect} from 'react';
const reducer = function(state, action){
    switch(action.type){
        case "SWITCH":
            return !state;
        default:
            throw new Error("Unknown action");
    }
}
const MenuPage = () =>{
    const [isOpen, isOpenDispatch] = useReducer(reducer,true);
然後綁定在本來的Context的setOpenContext上
import React, { useState, useReducer,useMemo } from 'react';
import useMouseY from '../util/useMouseY';
import MenuItem from '../component/MenuItem';
import Menu from '../component/Menu';
import { OpenContext } from '../context/ControlContext';
let menuItemWording=[
    "Like的發問",
    "Like的回答",
    "Like的文章",
    "Like的留言"
];
const reducer = function(state, action){
    switch(action.type){
        case "SWITCH":
            return !state;
        default:
            throw new Error("Unknown action");
    }
}
const MenuPage = () =>{
    const [isOpen, isOpenDispatch] = useReducer(reducer,true);
    const [menuItemData, setMenuItemData] = useState(menuItemWording);
    let menuItemArr = useMemo(()=>menuItemData.map((wording) => <MenuItem text={wording} key={wording}/>),[menuItemData]);
    return (
        <OpenContext.Provider value={{ 
            openContext: isOpen, 
            setOpenContext: isOpenDispatch
        }} >
            <Menu title={"Andy Chang的like"}>
                {menuItemArr}
            </Menu>
            <button onClick={()=>{
                let menuDataCopy = ["測試資料"].concat(menuItemData);
                setMenuItemData(menuDataCopy); 
            }}>更改第一個menuItem</button>
        </OpenContext.Provider>
    );
}
export default MenuPage;
最後讓呼叫dispatch的Menu不是直接傳isOpen新的值,而是傳入要使用的type:SWITCH即可:
import React, {useContext, useMemo} from 'react';
import { OpenContext } from '../context/ControlContext';
const menuContainerStyle = {
    position: "relative",
    width: "300px",
    padding: "14px",
    fontFamily: "Microsoft JhengHei",
    paddingBottom: "7px",
    backgroundColor: "white",
    border: "1px solid #E5E5E5",
};
const menuTitleStyle = {
    marginBottom: "7px",
    fontWeight: "bold",
    color: "#00a0e9",
    cursor: "pointer",
};
const menuBtnStyle = {
    position: "absolute",
    right: "7px",
    top: "33px",
    backgroundColor: "transparent",
    border: "none",
    color: "#00a0e9",
    outline: "none"
}
function Menu(props){
    const isOpenUtil = useContext(OpenContext);
    return (
        <div style={menuContainerStyle}>
            <p style={menuTitleStyle}>{props.title}</p>
            <button style={menuBtnStyle} onClick={
                ()=>{isOpenUtil.setOpenContext({type: "SWITCH"})}}>
                {(isOpenUtil.openContext)?"^":"V"}
            </button>
            <ul>{props.children}</ul>
        </div>
    );
}
export default Menu;
這樣我們就能保護isOpen,不會哪天出現isOpen被變成非布林值的狀況。